Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 1ae3566bf45a34d9adb3123773af322e96b917fb


Parents : cb8df34
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-04-23T17:04:46-05:00

feat(tests): add HTTP tests for identity switch API, covering response validation and error handling

Changes
Diff

diff --git a/tests/backend/test_identity_switch.py b/tests/backend/test_identity_switch.py
index 77864f0f..39d8c30b 100644
--- a/tests/backend/test_identity_switch.py
+++ b/tests/backend/test_identity_switch.py
@@ -1,5 +1,6 @@
# SPDX-License-Identifier: 0BSD
+import json
import os
import shutil
import tempfile
@@ -136,6 +137,10 @@ async def test_hotswap_identity_success(mock_rns, temp_dir):
app.teardown_identity.assert_called_once()
app.setup_identity.assert_called_once_with(new_id_instance)
app.websocket_broadcast.assert_called_once()
+ ws_payload = json.loads(app.websocket_broadcast.call_args[0][0])
+ assert ws_payload["type"] == "identity_switched"
+ assert ws_payload["identity_hash"] == new_hash
+ assert ws_payload["display_name"] == "New User"
# Verify main identity file was updated
main_identity_file = os.path.join(temp_dir, "identity")

diff --git a/tests/backend/test_identity_switch_http_api.py b/tests/backend/test_identity_switch_http_api.py
new file mode 100644
index 00000000..600efbea
--- /dev/null
+++ b/tests/backend/test_identity_switch_http_api.py
@@ -0,0 +1,129 @@
+# SPDX-License-Identifier: 0BSD
+
+"""HTTP tests for POST /api/v1/identities/switch (hotswap response shape, guards)."""
+
+from __future__ import annotations
+
+import asyncio
+from unittest.mock import AsyncMock
+
+import pytest
+from aiohttp import web
+from aiohttp.test_utils import TestClient, TestServer
+
+pytestmark = pytest.mark.usefixtures("require_loopback_tcp")
+
+
+def _build_aio_app(app):
+ routes = web.RouteTableDef()
+ auth_mw, mime_mw, sec_mw = app._define_routes(routes)
+ aio_app = web.Application(middlewares=[auth_mw, mime_mw, sec_mw])
+ aio_app.add_routes(routes)
+ return aio_app
+
+
+@pytest.fixture
+def web_identity_app(mock_app):
+ mock_app.current_context.running = True
+ mock_app.config.auth_enabled.set(False)
+ return mock_app
+
+
+@pytest.mark.asyncio
+async def test_post_identities_switch_hotswap_response_includes_hash_and_display_name(
+ web_identity_app,
+):
+ web_identity_app.hotswap_identity = AsyncMock(return_value=True)
+ expected_display = web_identity_app.config.display_name.get()
+
+ aio_app = _build_aio_app(web_identity_app)
+ body = {"identity_hash": "alt_identity_hash", "keep_alive": False}
+ async with TestClient(TestServer(aio_app)) as client:
+ r = await client.post("/api/v1/identities/switch", json=body)
+ assert r.status == 200
+ data = await r.json()
+ assert data["hotswapped"] is True
+ assert data["identity_hash"] == "alt_identity_hash"
+ assert data["display_name"] == expected_display
+ assert "message" in data
+ web_identity_app.hotswap_identity.assert_awaited_once_with(
+ "alt_identity_hash",
+ keep_alive=False,
+ )
+
+
+@pytest.mark.asyncio
+async def test_post_identities_switch_passes_keep_alive(web_identity_app):
+ web_identity_app.hotswap_identity = AsyncMock(return_value=True)
+ aio_app = _build_aio_app(web_identity_app)
+ async with TestClient(TestServer(aio_app)) as client:
+ r = await client.post(
+ "/api/v1/identities/switch",
+ json={"identity_hash": "id_x", "keep_alive": True},
+ )
+ assert r.status == 200
+ web_identity_app.hotswap_identity.assert_awaited_once_with("id_x", keep_alive=True)
+
+
+@pytest.mark.asyncio
+async def test_post_identities_switch_503_when_not_running(web_identity_app):
+ web_identity_app.current_context.running = False
+ web_identity_app.hotswap_identity = AsyncMock(return_value=True)
+ aio_app = _build_aio_app(web_identity_app)
+ async with TestClient(TestServer(aio_app)) as client:
+ r = await client.post(
+ "/api/v1/identities/switch",
+ json={"identity_hash": "any"},
+ )
+ assert r.status == 503
+ web_identity_app.hotswap_identity.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_post_identities_switch_hotswap_false_missing_identity_returns_500(
+ web_identity_app,
+):
+ web_identity_app.hotswap_identity = AsyncMock(return_value=False)
+ aio_app = _build_aio_app(web_identity_app)
+ async with TestClient(TestServer(aio_app)) as client:
+ r = await client.post(
+ "/api/v1/identities/switch",
+ json={"identity_hash": "missing_alt"},
+ )
+ assert r.status == 500
+ body = await r.json()
+ assert "Failed to switch identity" in (body.get("message") or "")
+
+
+@pytest.mark.asyncio
+async def test_post_identities_switch_concurrent_posts_each_invoke_hotswap(
+ web_identity_app,
+):
+ """Two overlapping switch requests should both complete (no dropped handler)."""
+ calls = {"n": 0}
+
+ async def slow_hotswap(identity_hash, keep_alive=False):
+ calls["n"] += 1
+ await asyncio.sleep(0.02)
+ return True
+
+ web_identity_app.hotswap_identity = slow_hotswap
+ aio_app = _build_aio_app(web_identity_app)
+
+ async with TestClient(TestServer(aio_app)) as client:
+ results = await asyncio.gather(
+ client.post(
+ "/api/v1/identities/switch",
+ json={"identity_hash": "concurrent_a"},
+ ),
+ client.post(
+ "/api/v1/identities/switch",
+ json={"identity_hash": "concurrent_b"},
+ ),
+ )
+ assert all(resp.status == 200 for resp in results)
+ bodies = [await resp.json() for resp in results]
+ assert all(b.get("hotswapped") is True for b in bodies)
+ hashes = {b.get("identity_hash") for b in bodies}
+ assert hashes == {"concurrent_a", "concurrent_b"}
+ assert calls["n"] == 2

diff --git a/tests/frontend/AppIdentitySwitch.test.js b/tests/frontend/AppIdentitySwitch.test.js
new file mode 100644
index 00000000..b5ecb24c
--- /dev/null
+++ b/tests/frontend/AppIdentitySwitch.test.js
@@ -0,0 +1,162 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import App from "../../meshchatx/src/frontend/components/App.vue";
+import ToastUtils from "../../meshchatx/src/frontend/js/ToastUtils";
+import GlobalEmitter from "../../meshchatx/src/frontend/js/GlobalEmitter";
+import GlobalState from "../../meshchatx/src/frontend/js/GlobalState";
+
+vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({
+ default: {
+ success: vi.fn(),
+ error: vi.fn(),
+ warning: vi.fn(),
+ info: vi.fn(),
+ },
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/GlobalEmitter", () => ({
+ default: {
+ emit: vi.fn(),
+ on: vi.fn(),
+ off: vi.fn(),
+ },
+}));
+
+function makeCtx() {
+ return {
+ _identitySwitchDedupeHash: null,
+ _identitySwitchDedupeAt: 0,
+ isSwitchingIdentity: true,
+ getConfig: vi.fn().mockResolvedValue(undefined),
+ updateRingtonePlayer: vi.fn().mockResolvedValue(undefined),
+ getAppInfo: vi.fn().mockResolvedValue(undefined),
+ $t: (key) => key,
+ };
+}
+
+describe("App.vue applyIdentitySwitched", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ GlobalState.unreadConversationsCount = 3;
+ });
+
+ afterEach(() => {
+ GlobalState.unreadConversationsCount = 0;
+ vi.useRealTimers();
+ });
+
+ it("applies identity switch, resets unread, and clears overlay", async () => {
+ const ctx = makeCtx();
+ await App.methods.applyIdentitySwitched.call(ctx, {
+ identity_hash: "h1",
+ display_name: "User One",
+ });
+ expect(ToastUtils.success).toHaveBeenCalledWith("identities.switched");
+ expect(GlobalState.unreadConversationsCount).toBe(0);
+ expect(ctx.getConfig).toHaveBeenCalledTimes(1);
+ expect(ctx.updateRingtonePlayer).toHaveBeenCalledTimes(1);
+ expect(ctx.getAppInfo).toHaveBeenCalledTimes(1);
+ expect(ctx.isSwitchingIdentity).toBe(false);
+ expect(GlobalEmitter.emit).toHaveBeenCalledWith(
+ "identity-switched",
+ expect.objectContaining({
+ identity_hash: "h1",
+ display_name: "User One",
+ })
+ );
+ });
+
+ it("dedupes rapid duplicate applies for the same hash (WS + HTTP race)", async () => {
+ const ctx = makeCtx();
+ await App.methods.applyIdentitySwitched.call(ctx, {
+ identity_hash: "same",
+ display_name: "First",
+ });
+ await App.methods.applyIdentitySwitched.call(ctx, {
+ identity_hash: "same",
+ display_name: "Second",
+ });
+ expect(ctx.getConfig).toHaveBeenCalledTimes(1);
+ expect(ToastUtils.success).toHaveBeenCalledTimes(1);
+ expect(GlobalEmitter.emit).toHaveBeenCalledTimes(1);
+ });
+
+ it("applies again for a different identity hash", async () => {
+ const ctx = makeCtx();
+ await App.methods.applyIdentitySwitched.call(ctx, {
+ identity_hash: "h1",
+ display_name: "A",
+ });
+ await App.methods.applyIdentitySwitched.call(ctx, {
+ identity_hash: "h2",
+ display_name: "B",
+ });
+ expect(ctx.getConfig).toHaveBeenCalledTimes(2);
+ expect(ToastUtils.success).toHaveBeenCalledTimes(2);
+ });
+
+ it("no-ops when identity_hash is empty", async () => {
+ const ctx = makeCtx();
+ await App.methods.applyIdentitySwitched.call(ctx, {
+ identity_hash: "",
+ display_name: "X",
+ });
+ expect(ctx.getConfig).not.toHaveBeenCalled();
+ expect(GlobalEmitter.emit).not.toHaveBeenCalled();
+ });
+
+ it("no-ops when identity_hash is missing", async () => {
+ const ctx = makeCtx();
+ await App.methods.applyIdentitySwitched.call(ctx, {
+ display_name: "X",
+ });
+ expect(ctx.getConfig).not.toHaveBeenCalled();
+ });
+
+ it("re-applies same hash after dedupe window expires", async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(1_000_000);
+ const ctx = makeCtx();
+ await App.methods.applyIdentitySwitched.call(ctx, {
+ identity_hash: "h1",
+ display_name: "A",
+ });
+ expect(ctx.getConfig).toHaveBeenCalledTimes(1);
+ vi.setSystemTime(1_000_000 + 10_001);
+ vi.clearAllMocks();
+ await App.methods.applyIdentitySwitched.call(ctx, {
+ identity_hash: "h1",
+ display_name: "B",
+ });
+ expect(ctx.getConfig).toHaveBeenCalledTimes(1);
+ expect(ToastUtils.success).toHaveBeenCalledTimes(1);
+ expect(GlobalEmitter.emit).toHaveBeenCalledTimes(1);
+ });
+
+ it("performance: dedupe path skips async work for many duplicate applies", async () => {
+ const ctx = makeCtx();
+ await App.methods.applyIdentitySwitched.call(ctx, {
+ identity_hash: "hot",
+ display_name: "A",
+ });
+ vi.clearAllMocks();
+ const t0 = performance.now();
+ const n = 2000;
+ for (let i = 0; i < n; i++) {
+ await App.methods.applyIdentitySwitched.call(ctx, {
+ identity_hash: "hot",
+ display_name: "A",
+ });
+ }
+ expect(performance.now() - t0).toBeLessThan(1500);
+ expect(ctx.getConfig).not.toHaveBeenCalled();
+ expect(GlobalEmitter.emit).not.toHaveBeenCalled();
+ });
+
+ it("onIdentitySwitchedApplyShell delegates to this.applyIdentitySwitched", async () => {
+ const inner = vi.fn().mockResolvedValue(undefined);
+ const ctx = { applyIdentitySwitched: inner };
+ App.methods.onIdentitySwitchedApplyShell.call(ctx, { identity_hash: "x", display_name: "Y" });
+ await Promise.resolve();
+ expect(inner).toHaveBeenCalledWith({ identity_hash: "x", display_name: "Y" });
+ });
+});

diff --git a/tests/frontend/IdentitiesPage.test.js b/tests/frontend/IdentitiesPage.test.js
index e6ac1f90..daec7ecd 100644
--- a/tests/frontend/IdentitiesPage.test.js
+++ b/tests/frontend/IdentitiesPage.test.js
@@ -1,6 +1,7 @@
import { mount } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import IdentitiesPage from "@/components/settings/IdentitiesPage.vue";
+import GlobalEmitter from "@/js/GlobalEmitter";
// Mock dependencies
vi.mock("@/js/ToastUtils", () => ({
@@ -8,6 +9,7 @@ vi.mock("@/js/ToastUtils", () => ({
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
+ info: vi.fn(),
},
}));
@@ -54,7 +56,13 @@ describe("IdentitiesPage.vue", () => {
}
return Promise.resolve({ data: {} });
}),
- post: vi.fn().mockResolvedValue({ data: { hotswapped: true } }),
+ post: vi.fn().mockResolvedValue({
+ data: {
+ hotswapped: true,
+ identity_hash: "hash2",
+ display_name: "Identity 2",
+ },
+ }),
delete: vi.fn().mockResolvedValue({ data: {} }),
};
window.api = axiosMock;
@@ -145,6 +153,71 @@ describe("IdentitiesPage.vue", () => {
expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/identities/switch", {
identity_hash: "hash2",
});
+ expect(GlobalEmitter.emit).toHaveBeenCalledWith(
+ "identity-switched-apply",
+ expect.objectContaining({
+ identity_hash: "hash2",
+ display_name: "Identity 2",
+ })
+ );
+ });
+
+ it("emits identity-switched-apply using list row when API omits hash fields (legacy server)", async () => {
+ axiosMock.post.mockResolvedValue({ data: { hotswapped: true } });
+ const wrapper = mountPage();
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.$nextTick();
+
+ const switchButton = wrapper.findAll("button").find((b) => b.attributes("title") === "identities.switch");
+ await switchButton.trigger("click");
+
+ expect(GlobalEmitter.emit).toHaveBeenCalledWith(
+ "identity-switched-apply",
+ expect.objectContaining({
+ identity_hash: "hash2",
+ display_name: "Identity 2",
+ })
+ );
+ });
+
+ it("emits identity-switching-start before identity-switched-apply", async () => {
+ const wrapper = mountPage();
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.$nextTick();
+
+ const switchButton = wrapper.findAll("button").find((b) => b.attributes("title") === "identities.switch");
+ await switchButton.trigger("click");
+
+ const names = GlobalEmitter.emit.mock.calls.map((c) => c[0]);
+ const startAt = names.indexOf("identity-switching-start");
+ const applyAt = names.indexOf("identity-switched-apply");
+ expect(startAt).toBeGreaterThanOrEqual(0);
+ expect(applyAt).toBeGreaterThanOrEqual(0);
+ expect(startAt).toBeLessThan(applyAt);
+ });
+
+ it("schedules window.location.reload when hotswap is not used", async () => {
+ const reloadFn = vi.fn();
+ vi.stubGlobal("location", { ...window.location, reload: reloadFn });
+ axiosMock.post.mockResolvedValue({
+ data: { hotswapped: false, should_restart: true },
+ });
+ vi.useFakeTimers();
+ try {
+ const wrapper = mountPage();
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.$nextTick();
+
+ const switchButton = wrapper.findAll("button").find((b) => b.attributes("title") === "identities.switch");
+ await switchButton.trigger("click");
+
+ expect(reloadFn).not.toHaveBeenCalled();
+ await vi.advanceTimersByTimeAsync(2000);
+ expect(reloadFn).toHaveBeenCalledTimes(1);
+ } finally {
+ vi.unstubAllGlobals();
+ vi.useRealTimers();
+ }
});
it("performance: measures identity list rendering for many identities", async () => {


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────